import os
import shutil
import random
import hashlib
import datetime

import web.db

from errors import *
from db import fetch_object, what, StringField, BoolField
from cli import Command
from log import getLogger

class _ExceptionWrapper(object):
	def __init__(self, to_catch, to_raise):
		self.to_catch = to_catch
		self.to_raise = to_raise

	def __enter__(self):
		pass

	def __exit__(self, type, value, traceback):
		if isinstance(value, self.to_catch):
			raise self.to_raise(value)

class Repository(object):
	class fields(object):
		classifier_name = StringField(maxlen=255)
		project_name = StringField(maxlen=255)
		project_metadata_version = StringField(maxlen=64)
		project_author = StringField(maxlen=128)
		project_home_page = StringField(maxlen=200)
		project_download_url = StringField(maxlen=200)
		project_author_email = StringField(maxlen=255)
		project_license = StringField(maxlen=32)
		project_summary = StringField()
		project_description = StringField()
		release_version = StringField(maxlen=32)
		release_filename = StringField(maxlen=100)
		release_md5_digest = StringField(maxlen=255)
		release_platform = StringField(maxlen=128)
		release_signature = StringField(maxlen=128)
		release_filetype = StringField(maxlen=255)
		release_pyversion = StringField(maxlen=32)
		user_name = StringField(maxlen=30)
		user_email = StringField(maxlen=75, null=True, blank=False)
		user_salt = StringField(maxlen=15)
		user_password = StringField(maxlen=128)
		user_is_locked = BoolField(default=False)

	DDL_SCHEMA = '''
BEGIN;
CREATE TABLE "classifier" (
	"id" integer NOT NULL PRIMARY KEY,
	"name" {fields.classifier_name.ddl} UNIQUE
)
;
CREATE TABLE "project_classifier" (
	"id" integer NOT NULL PRIMARY KEY,
	"project_id" integer NOT NULL REFERENCES "project" ("id"),
	"classifier_id" integer NOT NULL REFERENCES "classifier" ("id"),
	UNIQUE ("project_id", "classifier_id")
)
;
CREATE TABLE "project" (
	"id" integer NOT NULL PRIMARY KEY,
	"name" {fields.project_name.ddl} UNIQUE,
	"license" {fields.project_license.ddl},
	"metadata_version" {fields.project_metadata_version.ddl},
	"author" {fields.project_author.ddl},
	"home_page" {fields.project_home_page.ddl},
	"download_url" {fields.project_download_url.ddl},
	"summary" {fields.project_summary.ddl},
	"description" {fields.project_description.ddl},
	"author_email" {fields.project_author_email.ddl},
	"owner_id" integer NOT NULL REFERENCES "user" ("id"),
	"updated" datetime NOT NULL
)
;
CREATE TABLE "release" (
	"id" integer NOT NULL PRIMARY KEY,
	"version" {fields.release_version.ddl},
	"filename" {fields.release_filename.ddl},
	"md5_digest" {fields.release_md5_digest.ddl},
	"platform" {fields.release_platform.ddl},
	"signature" {fields.release_signature.ddl},
	"filetype" {fields.release_filetype.ddl},
	"pyversion" {fields.release_pyversion.ddl},
	"project_id" integer NOT NULL REFERENCES "project" ("id"),
	"upload_time" datetime NOT NULL,
	UNIQUE ("project_id", "version", "platform", "pyversion")
)
;
CREATE TABLE "user" (
	"id" integer NOT NULL PRIMARY KEY,
	"name" {fields.user_name.ddl} UNIQUE,
	"email" {fields.user_email.ddl},
	"is_locked" {fields.user_is_locked.ddl},
	"salt" {fields.user_salt.ddl},
	"password" {fields.user_password.ddl}
)
;
COMMIT;
'''.format(fields=fields)
	DIST_STORAGE_PREFIX = 'static'
	DB_ENGINE = 'sqlite'
	DB_NAME = 'webpyshop.db'

	log = getLogger('repo')

	def __init__(self, path, allow_release_overwrites=False):
		self.path = path
		self.db_path = os.path.join(self.path, self.DB_NAME)
		self.dist_storage_path = os.path.join(self.path, self.DIST_STORAGE_PREFIX)
		self.allow_release_overwrites = allow_release_overwrites

	def _open_db(self):
		db = web.db.database(dbn=self.DB_ENGINE, db=self.db_path)
		db.printing = False
		return db

	def build(self, force=False):
		if os.path.exists(self.path):
			if not os.path.isdir(self.path):
				raise IntegrityError('repository path exists and is not a directory')
		elif force:
			self.log.debug('creating repository path (%s)', self.path)
			os.makedirs(self.path)
		else:
			raise IntegrityError('repository path does not exist')

		if os.path.exists(self.dist_storage_path):
			try:
				items = os.listdir(self.dist_storage_path)
			except OSError:
				raise IntegrityError('distribution storage exists and is not a directory')

			if items:
				if force:
					self.log.debug('cleaning distribution storage directory (%s)', self.dist_storage_path)
					shutil.rmtree(self.dist_storage_path)
					os.mkdir(self.dist_storage_path)
				else:
					raise IntegrityError('distribution storage is not empty')
		else:
			os.mkdir(self.dist_storage_path)

		if os.path.exists(self.db_path):
			if force:
				self.log.debug('removing existing database')
				os.remove(self.db_path)
			else:
				raise IntegrityError('repository database already exists')

		self.log.debug('initializing database (%s)', self.db_path)
		db = self._open_db()
		self._exec_sql_script(self.DDL_SCHEMA, db=db)
		self.log.info('done initializing repository (%s)', self.path)

	def open(self):
		self.log.info('opening repository (%s)', self.path)
		self.log.debug('checking if distribution storage is writeable')
		try:
			open(os.path.join(self.dist_storage_path, '.keep_me'), 'w').close()
		except IOError:
			raise IntegrityError('distribution storage is not writable')

		self.log.debug('opening database')
		self.db = self._open_db()
		self.db_exc_context = _ExceptionWrapper(self.db.db_module.Error, IntegrityError)

		self.log.debug('checking database integrity')
		with self.db_exc_context:
			# sqlite3 db engine specific set up
			list(self.db.select('sqlite_master'))
			self._exec_sql_script('PRAGMA foreign_keys = ON;')

		return self

	def _get_distr_file(self, filename):
		return os.path.join(self.dist_storage_path, filename)

	@classmethod
	def encrypt_password(cls, password, salt=None):
		if not len(password):
			raise ValueError('password must not be empty')
		salt = cls.fields.user_salt(salt or '{0:08x}'.format(random.getrandbits(32)))
		return hashlib.md5(salt + password).hexdigest(), salt

	def add_user(self, name, is_locked, email, password):
		password, salt = self.encrypt_password(password)
		self.log.debug('adding user "%s"', name)
		with self.db_exc_context:
			return self.db.insert('user',
				name=self.fields.user_name(name),
				is_locked=self.fields.user_is_locked(is_locked),
				email=self.fields.user_email(email),
				password=self.fields.user_password(password),
				salt=self.fields.user_salt(salt))

	def user_exists(self, user_name):
		try:
			self.log.debug('checking if user "%s" exists', user_name)
			with self.db_exc_context:
				fetch_object(self.db.where('user', name=user_name))
			return True
		except ObjectDoesNotExist:
			return False

	def authenticate_user(self, user_name, password):
		try:
			user = self.get_user(user_name, ('id', 'is_locked', 'password', 'salt'))
		except ObjectDoesNotExist:
			raise AuthenticationError('user does not exist')

		if user.is_locked:
			raise AuthenticationError('user is locked')

		if self.encrypt_password(password, user.salt) != (user.password, user.salt):
			raise AuthenticationError('invalid user password')

		return user.id

	def get_user(self, user_name, fields=None):
		with self.db_exc_context:
			return fetch_object(self.db.where('user', what=what(fields), name=user_name))

	def update_user(self, user_id, **data):
		if not data:
			raise TypeError('no user attributes specified')

		user_data = dict()
		def add_user_data(key, field):
			if key in data:
				user_data[key] = field(data.pop(key))
		add_user_data('name', self.fields.user_name)
		add_user_data('is_locked', self.fields.user_is_locked)
		add_user_data('email', self.fields.user_email)
		if 'password' in data:
			password, salt = self.encrypt_password(data.pop('password'))
			user_data['password'] = self.fields.user_password(password)
			user_data['salt'] = self.fields.user_salt(salt)

		if len(data):
			raise TypeError('invalid user attributes', data.keys())

		with self.db_exc_context:
			if not self.db.update('user', where='id = $id', vars={'id': user_id}, **user_data):
				raise ObjectDoesNotExist('no user matching id: {}'.format(user_id))

	def delete_user(self, user_name):
		with self.db_exc_context:
			if not self.db.delete('user', where='name = $name', vars=dict(name=user_name)):
				raise ObjectDoesNotExist('no user matching name: {}'.format(user_name))

	def list_users(self, fields=None):
		with self.db_exc_context:
			return self.db.select('user', what=what(fields), order='name')

	def add_or_update_project(self, owner_id, project_id=None, classifiers=[], **data):
		if not data:
			raise TypeError('no project data supplied')

		project_data = dict(updated = datetime.datetime.utcnow())
		def add_project_data(key, field):
			if key in data:
				project_data[key] = field(data.pop(key))

		add_project_data('name', self.fields.project_name)
		add_project_data('metadata_version', self.fields.project_metadata_version)
		add_project_data('author', self.fields.project_author)
		add_project_data('author_email', self.fields.project_author_email)
		add_project_data('home_page', self.fields.project_home_page)
		add_project_data('download_url', self.fields.project_download_url)
		add_project_data('license', self.fields.project_license)
		add_project_data('summary', self.fields.project_summary)
		add_project_data('description', self.fields.project_description)

		if len(data):
			raise TypeError('invalid project data fields', data.keys())

		with self.db_exc_context:
			if project_id:
				# updating existing project
				self.db.update('project', where='id = $id', vars=dict(id=project_id), **project_data)
				self.db.delete('project_classifier', where='project_id = $project_id', vars=dict(project_id=project_id))
			else:
				# creating new project
				project_id = self.db.insert('project', owner_id=owner_id, **project_data)

			for id in classifiers:
				self.db.insert('project_classifier', project_id=project_id, classifier_id=id)

		return project_id

	def list_projects(self, fields=None):
		with self.db_exc_context:
			return self.db.select(tables='project', what=what(fields), order='name')

	def get_project(self, name, fields=None):
		with self.db_exc_context:
			return fetch_object(self.db.where('project', what=what(fields), name=name))

	def add_or_update_release(self, project_id, fileobj=None, **data):
		release_data = dict(project_id=project_id)
		def add_release_data(key, field):
			if key in data:
				release_data[key] = field(key)

		add_release_data('version', self.fields.release_version)
		add_release_data('pyversion', self.fields.pyversion)
		add_release_data('platform', self.fields.platform)

		try:
			with self.db_exc_context:
				release = fetch_object(self.db.where('release', what='id, filename', **release_data))
			# release object found
			if self.release_overwrites:
				distr_filename = self._get_distr_file(release.filename)
				try:
					os.unlink(distr_filename)
				except OSError as error:
					if error.errno == 2: # No such file or directory
						self.log.warning('distribution file does not exist, nothing to remove: %s', distr_filename)
					else:
						raise
			else:
				raise SubmissionError('distribution file already exists', release.filename)
		except ObjectDoesNotExist:
			release = None

		# this together with filename field declaration guarantees that filename shall not be None
		add_release_data('filename', self.fields.release_filename)
		add_release_data('md5_digest', self.fields.release_md5_digest)
		add_release_data('signature', self.fields.signature)
		add_release_data('filetype', self.fields.filetype)

		release_data['upload_time'] = datetime.datetime.utcnow()

		with open(self._get_distr_file(release_data['filename']), 'wb') as dst_file:
			shutil.copyfileobj(src_file, dst_file)

		with self.db_exc_context:
			if release:
				self.db.update('release', where='id = $id', vars=dict(id=release.id), **release_data)
			else:
				self.db.insert('release', **release_data)

	def get_project_releases(self, project_id, fields=None, **criteria):
		with self.db_exc_context:
			return self.db.where('release',
				what=what(fields),
				order='version DESC',
				project_id=project_id, **criteria
			)

	def get_or_create_classifiers(self, names):
		def add_name(name):
			id = hash(name)
			self.db.query('INSERT OR IGNORE INTO classifier (id, name) VALUES ($id, $name)', vars=dict(id=id, name=name))
			return id
		with self.db_exc_context:
			return [add_name(n) for n in names]

	def _exec_sql_script(self, sql_script, db=None):
		(db or self.db).ctx.db.executescript(sql_script)


class RepoCommand(Command):
	def add_arguments(self, parser):
		parser.add_argument('-R', '--repo', help='location of the repository (default: cwd)')

	def _get_repo(self, args):
		return Repository(args.repo or os.curdir)

	def execute(self, args):
		self.repo = self._get_repo().open()


@Command.register('repo-init')
class RepoInitCommand(RepoCommand):
	'''Create a new empty repository'''

	def add_arguments(self, parser):
		super(RepoInitCommand, self).add_arguments(parser)
		parser.add_argument('--force', action='store_true', help='force repository creation')

	def execute(self, args):
		# NOTE: a little bit of breaking the principles of OOP here
		try:
			self._get_repo().build(force=args.force)
		except RepositoryError as error:
			self.repo.log.error('error creating repository: %s', error)


@Command.register('user-list')
class UserListCommand(RepoCommand):
	'''List users registered within the repository'''

	def execute(self, args):
		super(UserListCommand, self).execute(args)
		users = list(self.repo.open().list_users())
		self.stdout.writelines('{0.name} {0.email} {1}\n'.format(user, 'locked' if user.is_locked else 'active') for user in users)

@Command.register('user-add')
class UserAddCommand(RepoCommand):
	'''Add user to the repository'''

	def add_arguments(self, parser):
		super(UserAddCommand, self).add_arguments(parser)
		parser.add_argument('-e', '--email', default='foo@example.com')
		parser.add_argument('-l', '--lock', action='store_true', default=False, help='Lock user')
		parser.add_argument('user_name', help='User name')

	@staticmethod
	def read_password():
		import getpass

		for round in reversed(range(3)):
			password1 = getpass.getpass()
			if not password1:
				print('Password must not be empty, try again')
				continue

			password2 = getpass.getpass('Confirm password:')

			if password1 == password2:
				return password1
			elif not round:
				return
			else:
				print('Passwords do not match, try again')

	def execute(self, args):
		super(UserAddCommand, self).execute(args)

		if self.repo.user_exists(args.user_name):
			raise CommandError('user {0} already exists'.format(args.user_name))

		password = read_password()
		if password is None:
			raise CommandError('failed to read password')

		user_id = self.repo.add_user(args.user_name, args.lock, args.email, password)
		self.repo.log.info('user %s added (%d)', args.user_name, user_id)


@Command.register('user-update')
class UserUpdateCommand(RepoCommand):
	'''Modify user's attributes'''

	def add_arguments(self, parser):
		super(UserUpdateCommand, self).add_arguments(parser)
		parser.add_argument('-l','--lock', action='store_true', default=None)
		parser.add_argument('-u','--unlock', action='store_false', default=None)
		parser.add_argument('-p','--password', action='store_true', default=False)
		parser.add_argument('-e','--email', default=None)
		parser.add_argument('-n','--name', default=None)
		parser.add_argument('user_name', help='name of the user to modify')

	def execute(self, args):
		try:
			user_id = self.repo.get_user(args.user_name, 'id').id
		except ObjectDoesNotExist:
			raise CommandError('user {0} does not exist'.format(args.user_name))

		self.repo.update_user(user_id,
			name = args.user_name,
			is_locked = args.lock or args.unlock,
			email = args.email,
			**(dict(password=read_password()) if args.password else dict()))

		self.repo.log.info('user "%s" modified', user_name)


@Command.register('user-delete')
class UserDelCommand(RepoCommand):
	'''Delete user from repository'''

	def add_arguments(self, parser):
		super(UserDelCommand, self).add_arguments(parser)
		parser.add_argument('user_name', help='name of the user to delete')

	def execute(self, args):
		super(UserDelCommand, self).execute(args)
		try:
			self.repo.delete_user(args.user_name)
		except ObjectDoesNotExist as error:
			raise CommandError(error)

		self.repo.log.info('user "%" deleted', args.user_name)
